Accumulator-passing style


Author: Kimmy

在你所需要知道的关于函数的一切开篇我就提到了一种把 fibonacci 函数的树状递归改成迭代式尾递归形式的方法。这种方法一直以来都是了解函数式编程入门必须要了解的内容,但穷尽了我的语言能力也没办法给他一个合适的名称。

今天恰好在微信群挖坟的时候看到了这样一个概念,Accumulator-passing style,瞬间豁然开朗。尽管使用 Google 搜索了一下发现这个词也不是那么常见,首页也就只有几个相关的结果,但确实能把这个概念相对准确地描述清楚。

顺便根据 Google 搜索结果找到了几个更加简单的实现 accumulator-passing style 的说明文档。这里选一篇的内容摘录一下,附上我改过的实例。比起在之前的教程中绕来绕去通过循环转写的方式实现(思路来自 Alexander Stepanov 的 Elements of Programming)要更加简单直接。

尾递归

首先要解释的一个概念是尾递归。(这也是之前教程中遗漏的内容 😅)

尾递归(tail-recursion)是递归的一种特殊形式。即函数的最后一个动作是对自身的尾调用(tail call)。

一个尾调用是指函数的最后一个动作是返回一个函数的调用结果。这样可以通过简化函数栈帧来进行性能优化(tail call elimination,尾调用消除)。

比如:

function fact1(n) {
  if (n == 0) {
    return 1;
  }
  return n * fact1(n - 1);
}

function fact2(n, acc) {
  if (n == 0) {
    return acc;
  }
  return fact2(n - 1, n * acc);
}

在上面两个示例中,fact1 是常规的递归,fact2 则是尾递归的形式。因为在 fact1 中,除了对自身递归调用以外,还需要再拿到结果计算 n * _。而 fact2 中,最后的动作则只有简单的对自身的递归调用,所有的参数传递也是在调用之前就需要求值完成的。

改写成 accumulator-passing style 的步骤

只需要简单的 4 步,就可以轻松的把大部分递归改写成 accumulator style。这里以 factorial(阶乘)函数为例。

function fact(n) {
  if (n == 0) {
    return 1;
  }
  return n * fact(n - 1);
}

第一步,引入 accumulator 作为参数。

function factAcc(n, acc) {
  ...

因为 accumulator 在整个递归过程中用于累计递归的结算结果和额外的状态,所以显然把他作为参数传递给下一次调用是最好的办法。这里的 accumulator 并不仅限于一个参数。像之前提到的 fibonacci 函数,需要把前一次的结果也传递给下一次调用,也就出现两个额外参数的情况。

第二步,在递归的终结点返回 accumulator 的值。

function factAcc(n, acc) {
  if (n == 0) {
    return acc;
  }
  ...

前面说了,既然 accumulator 用于累计计算结果,那到了递归终止的时候,就应该把结果返回。在 factorial 函数的例子中,当我们递归到 0 的时候,已经把前面该累乘的结果计算进了 acc 里面。

第三步,递归调用当前函数,更新参数和 accumulator 的值。

function factAcc(n, acc) {
  if (n == 0) {
    return acc;
  }
  return factAcc(n - 1, acc * n);
}

这里是稍微困难的一步,即如何改写之前的递归调用点。这需要看对应的场景来确定如何计算累计结果。在 factorial 的例子中,就是简单的把每次递归的 n 累乘进来。而在其他很多场合,则需要更进一步的设计。如 fibonacci 的树状递归,本质上是前后两次计算结果的合并,处理起来就没这么明显。

第四步,写一个函数 wrap 刚才的尾递归函数,初始化 accumulator。

function fact(n) {
  return factAcc(n, 1);
}

例:fibonacci 函数

function fib(n) {
  if (n == 0 || n == 1) {
    return 1;
  }
  return fib(n - 1) + fib(n - 2);
}

第一步,引入 accumulator 作为参数。因为有两次递归调用,设计到结果合并,引入两个参数。

function fibAcc(n, a, b) {
  ...

第二步,在递归的终结点返回 accumulator 的值。

function fibAcc(n, a, b) {
  if (n == 0 || n == 1) {
    return a;
  }
  ...

第三步,递归调用当前函数,更新参数和 accumulator 的值。fibnacci 函数的定义,第 n 次结果是前两次结果(假设分别为 ab)的和(即 a + b),下一次再取到的“前两次结果”就变成了 ba + b

function fibAcc(n, a, b) {
  if (n == 0 || n == 1) {
    return a;
  }
  return fibAcc(n-1, b, a + b);
}

第四步,写一个函数 wrap 刚才的尾递归函数,初始化 accumulator。

function fib(n) {
  return fibAcc(n, 1, 1);
}

练习题

各位可以试着自行改写一下 sum(列表求和)、map(列表映射)和 fold(列表折叠)的实现。(前两个相对简单,第三个需要多思考一下)

其定义分别如下:


// sum([a, b, c]) = a + b + c
function sum(xs) {
  if (xs.length == 0) {
    return 0;
  }
  let [first, ...rest] = xs;
  return first + sum(rest);
}

// map([a, b, c], fn) = [fn(a), fn(b), fn(c)]
function map(xs, fn) {
  if (xs.length == 0) {
    return [];
  }
  let [first, ...rest] = xs;
  return [fn(first), ...map(rest, fn)];
}

// fold([a, b, c], fn, z) == fn(a, fn(b, fn(c, zero)))
function fold(xs, fn, zero) {
  if (xs.length == 0) {
    return zero;
  }
  let [first, ...rest] = xs;
  return fn(first, fold(rest, fn, zero));
}
创建时间:2021-02-24 最近更新时间:2023-11-03